| @@ -167,7 +167,7 @@ TIMEZONE="Pacific Time (US & Canada)" | ||
| 167 | 167 | FAILED_JOBS_TO_KEEP=100 | 
| 168 | 168 |  | 
| 169 | 169 | # Maximum runtime of background jobs in minutes | 
| 170 | -DELAYED_JOB_MAX_RUNTIME=20 | |
| 170 | +DELAYED_JOB_MAX_RUNTIME=2 | |
| 171 | 171 |  | 
| 172 | 172 | # Amount of seconds for delayed_job to sleep before checking for new jobs | 
| 173 | 173 | DELAYED_JOB_SLEEP_DELAY=10 | 
| @@ -1,12 +1,19 @@ | ||
| 1 | 1 | # Changes | 
| 2 | 2 |  | 
| 3 | +* Jul 30, 2015 - RssAgent can configure the order of events created via `events_order`. | |
| 4 | +* Jul 29, 2015 - WebsiteAgent can configure the order of events created via `events_order`. | |
| 5 | +* Jul 29, 2015 - DataOutputAgent can configure the order of events in the output via `events_order`. | |
| 3 | 6 | * Jul 20, 2015 - Control Links (used by the SchedularAgent) are correctly exported in Scenarios. | 
| 4 | 7 | * Jul 20, 2015 - keep\_events\_for was moved from days to seconds; Scenarios have a schema verison. | 
| 8 | +* Jul 8, 2015 - DataOutputAgent supports feed icon, and a new template variable `events`. | |
| 5 | 9 | * Jul 1, 2015 - DeDuplicationAgent properly handles destruction of memory. | 
| 6 | 10 | * Jun 26, 2015 - Add `max_events_per_run` to RssAgent. | 
| 7 | 11 | * Jun 19, 2015 - Add `url_from_event` to WebsiteAgent. | 
| 8 | 12 | * Jun 17, 2015 - RssAgent emits events for new feed items in chronological order. | 
| 13 | +* Jun 17, 2015 - Liquid filter `unescape` added. | |
| 14 | +* Jun 17, 2015 - Liquid filter `regex_replace` and `regex_replace_first` added, with escape sequence support. | |
| 9 | 15 | * Jun 15, 2015 - Liquid filter `uri_expand` added. | 
| 16 | +* Jun 13, 2015 - Liquid templating engine is upgraded to version 3. | |
| 10 | 17 | * Jun 12, 2015 - RSSAgent can now accept an array of URLs. | 
| 11 | 18 | * Jun 8, 2015 - WebsiteAgent includes a `use_namespaces` option to enable XML namespaces. | 
| 12 | 19 | * May 27, 2015 - Validation warns user if they have not provided a `path` when using JSONPath in WebsiteAgent. | 
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | source 'https://rubygems.org' | 
| 2 | 2 |  | 
| 3 | +# Ruby 2.0 is the minimum requirement | |
| 4 | +ruby ['2.0.0', RUBY_VERSION].max | |
| 5 | + | |
| 3 | 6 | # Optional libraries. To conserve RAM, comment out any that you don't need, | 
| 4 | 7 | # then run `bundle` and commit the updated Gemfile and Gemfile.lock. | 
| 5 | 8 | gem 'twilio-ruby', '~> 3.11.5' # TwilioAgent | 
| @@ -235,7 +235,7 @@ GEM | ||
| 235 | 235 | launchy (2.4.2) | 
| 236 | 236 | addressable (~> 2.3) | 
| 237 | 237 | libv8 (3.16.14.7) | 
| 238 | - liquid (3.0.3) | |
| 238 | + liquid (3.0.6) | |
| 239 | 239 | listen (2.7.9) | 
| 240 | 240 | celluloid (>= 0.15.2) | 
| 241 | 241 | rb-fsevent (>= 0.9.3) | 
| @@ -580,6 +580,3 @@ DEPENDENCIES | ||
| 580 | 580 | weibo_2! | 
| 581 | 581 | wunderground (~> 1.2.0) | 
| 582 | 582 | xmpp4r (~> 0.5.6) | 
| 583 | - | |
| 584 | -BUNDLED WITH | |
| 585 | - 1.10.5 | 
| @@ -80,9 +80,11 @@ All agents have specs! Test all specs with `bundle exec rspec`, or test a specif | ||
| 80 | 80 |  | 
| 81 | 81 | ## Deployment | 
| 82 | 82 |  | 
| 83 | -[](https://heroku.com/deploy) (Takes a few minutes to setup. Be sure to click 'View it' after launch!) | |
| 83 | +Try Huginn on Heroku: [](https://heroku.com/deploy) (Takes a few minutes to setup. Be sure to click 'View it' after launch!) | |
| 84 | 84 |  | 
| 85 | -Huginn can run on Heroku for free! Please see [the Huginn Wiki](https://github.com/cantino/huginn/wiki#deploying-huginn) for detailed deployment strategies for different providers. | |
| 85 | +Huginn works on the free version of Heroku [with limitations](https://github.com/cantino/huginn/wiki/Run-Huginn-for-free-on-Heroku). For non-experimental use, we recommend Heroku's cheapest paid plan or our Docker container. | |
| 86 | + | |
| 87 | +Please see [the Huginn Wiki](https://github.com/cantino/huginn/wiki#deploying-huginn) for detailed deployment strategies for different providers. | |
| 86 | 88 |  | 
| 87 | 89 | ### Optional Setup | 
| 88 | 90 |  | 
| @@ -1,10 +1,8 @@ | ||
| 1 | 1 | module DryRunnable | 
| 2 | - def dry_run! | |
| 3 | - readonly! | |
| 2 | + extend ActiveSupport::Concern | |
| 4 | 3 |  | 
| 5 | - class << self | |
| 6 | - prepend Sandbox | |
| 7 | - end | |
| 4 | + def dry_run! | |
| 5 | + @dry_run = true | |
| 8 | 6 |  | 
| 9 | 7 | log = StringIO.new | 
| 10 | 8 | @dry_run_logger = Logger.new(log) | 
| @@ -14,6 +12,7 @@ module DryRunnable | ||
| 14 | 12 |  | 
| 15 | 13 | begin | 
| 16 | 14 |        raise "#{short_type} does not support dry-run" unless can_dry_run? | 
| 15 | + readonly! | |
| 17 | 16 | check | 
| 18 | 17 | rescue => e | 
| 19 | 18 |        error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}" | 
| @@ -23,28 +22,38 @@ module DryRunnable | ||
| 23 | 22 | memory: memory, | 
| 24 | 23 | log: log.string, | 
| 25 | 24 | ) | 
| 25 | + ensure | |
| 26 | + @dry_run = false | |
| 26 | 27 | end | 
| 27 | 28 |  | 
| 28 | 29 | def dry_run? | 
| 29 | - is_a? Sandbox | |
| 30 | + !!@dry_run | |
| 31 | + end | |
| 32 | + | |
| 33 | + included do | |
| 34 | + prepend Wrapper | |
| 30 | 35 | end | 
| 31 | 36 |  | 
| 32 | - module Sandbox | |
| 37 | + module Wrapper | |
| 33 | 38 | attr_accessor :results | 
| 34 | 39 |  | 
| 35 | 40 | def logger | 
| 41 | + return super unless dry_run? | |
| 36 | 42 | @dry_run_logger | 
| 37 | 43 | end | 
| 38 | 44 |  | 
| 39 | - def save | |
| 40 | - valid? | |
| 45 | +    def save(options = {}) | |
| 46 | + return super unless dry_run? | |
| 47 | + perform_validations(options) | |
| 41 | 48 | end | 
| 42 | 49 |  | 
| 43 | - def save! | |
| 44 | - save or raise ActiveRecord::RecordNotSaved | |
| 50 | +    def save!(options = {}) | |
| 51 | + return super unless dry_run? | |
| 52 | + save(options) or raise_record_invalid | |
| 45 | 53 | end | 
| 46 | 54 |  | 
| 47 | 55 |      def log(message, options = {}) | 
| 56 | + return super unless dry_run? | |
| 48 | 57 | case options[:level] || 3 | 
| 49 | 58 | when 0..2 | 
| 50 | 59 | sev = Logger::DEBUG | 
| @@ -57,10 +66,12 @@ module DryRunnable | ||
| 57 | 66 | logger.log(sev, message) | 
| 58 | 67 | end | 
| 59 | 68 |  | 
| 60 | - def create_event(event_hash) | |
| 69 | + def create_event(event) | |
| 70 | + return super unless dry_run? | |
| 61 | 71 | if can_create_events? | 
| 62 | - @dry_run_results[:events] << event_hash[:payload] | |
| 63 | -        events.build({ user: user, expires_at: new_event_expiration_date }.merge(event_hash)) | |
| 72 | + event = build_event(event) | |
| 73 | + @dry_run_results[:events] << event.payload | |
| 74 | + event | |
| 64 | 75 | else | 
| 65 | 76 | error "This Agent cannot create events!" | 
| 66 | 77 | end | 
| @@ -0,0 +1,161 @@ | ||
| 1 | +module SortableEvents | |
| 2 | + extend ActiveSupport::Concern | |
| 3 | + | |
| 4 | + included do | |
| 5 | + validate :validate_events_order | |
| 6 | + end | |
| 7 | + | |
| 8 | + def description_events_order(*args) | |
| 9 | + self.class.description_events_order(*args) | |
| 10 | + end | |
| 11 | + | |
| 12 | + module ClassMethods | |
| 13 | + def can_order_created_events! | |
| 14 | + raise if cannot_create_events? | |
| 15 | + prepend AutomaticSorter | |
| 16 | + end | |
| 17 | + | |
| 18 | + def can_order_created_events? | |
| 19 | + include? AutomaticSorter | |
| 20 | + end | |
| 21 | + | |
| 22 | + def cannot_order_created_events? | |
| 23 | + !can_order_created_events? | |
| 24 | + end | |
| 25 | + | |
| 26 | + def description_events_order(events = 'events created in each run') | |
| 27 | + <<-MD.lstrip | |
| 28 | +        To specify the order of #{events}, set `events_order` to an array of sort keys, each of which looks like either `expression` or `[expression, type, descending]`, as described as follows: | |
| 29 | + | |
| 30 | + * _expression_ is a Liquid template to generate a string to be used as sort key. | |
| 31 | + | |
| 32 | + * _type_ (optional) is one of `string` (default), `number` and `time`, which specifies how to evaluate _expression_ for comparison. | |
| 33 | + | |
| 34 | + * _descending_ (optional) is a boolean value to determine if comparison should be done in descending (reverse) order, which defaults to `false`. | |
| 35 | + | |
| 36 | +        Sort keys listed earlier take precedence over ones listed later.  For example, if you want to sort articles by the date and then by the author, specify `[["{{date}}", "time"], "{{author}}"]`. | |
| 37 | + | |
| 38 | +        Sorting is done stably, so even if all events have the same set of sort key values the original order is retained.  Also, a special Liquid variable `_index_` is provided, which contains the zero-based index number of each event, which means you can exactly reverse the order of events by specifying `[["{{_index_}}", "number", true]]`. | |
| 39 | + MD | |
| 40 | + end | |
| 41 | + end | |
| 42 | + | |
| 43 | + def can_order_created_events? | |
| 44 | + self.class.can_order_created_events? | |
| 45 | + end | |
| 46 | + | |
| 47 | + def cannot_order_created_events? | |
| 48 | + self.class.cannot_order_created_events? | |
| 49 | + end | |
| 50 | + | |
| 51 | + def events_order | |
| 52 | + options['events_order'] | |
| 53 | + end | |
| 54 | + | |
| 55 | + module AutomaticSorter | |
| 56 | + def check | |
| 57 | + return super unless events_order | |
| 58 | + sorting_events do | |
| 59 | + super | |
| 60 | + end | |
| 61 | + end | |
| 62 | + | |
| 63 | + def receive(incoming_events) | |
| 64 | + return super unless events_order | |
| 65 | + # incoming events should be processed sequentially | |
| 66 | + incoming_events.each do |event| | |
| 67 | + sorting_events do | |
| 68 | + super([event]) | |
| 69 | + end | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + def create_event(event) | |
| 74 | + if @sortable_events | |
| 75 | + event = build_event(event) | |
| 76 | + @sortable_events << event | |
| 77 | + event | |
| 78 | + else | |
| 79 | + super | |
| 80 | + end | |
| 81 | + end | |
| 82 | + | |
| 83 | + private | |
| 84 | + | |
| 85 | + def sorting_events(&block) | |
| 86 | + @sortable_events = [] | |
| 87 | + yield | |
| 88 | + ensure | |
| 89 | + events, @sortable_events = @sortable_events, nil | |
| 90 | + sort_events(events).each do |event| | |
| 91 | + create_event(event) | |
| 92 | + end | |
| 93 | + end | |
| 94 | + end | |
| 95 | + | |
| 96 | + private | |
| 97 | + | |
| 98 | +  EXPRESSION_PARSER = { | |
| 99 | +    'string' => ->string { string }, | |
| 100 | +    'number' => ->string { string.to_f }, | |
| 101 | +    'time'   => ->string { Time.zone.parse(string) }, | |
| 102 | + } | |
| 103 | + EXPRESSION_TYPES = EXPRESSION_PARSER.keys.freeze | |
| 104 | + | |
| 105 | + def validate_events_order | |
| 106 | + case order_by = events_order | |
| 107 | + when nil | |
| 108 | + when Array | |
| 109 | + # Each tuple may be either [expression, type, desc] or just | |
| 110 | + # expression. | |
| 111 | + order_by.each do |expression, type, desc| | |
| 112 | + case expression | |
| 113 | + when String | |
| 114 | + # ok | |
| 115 | + else | |
| 116 | + errors.add(:base, "first element of each events_order tuple must be a Liquid template") | |
| 117 | + break | |
| 118 | + end | |
| 119 | + case type | |
| 120 | + when nil, *EXPRESSION_TYPES | |
| 121 | + # ok | |
| 122 | + else | |
| 123 | +          errors.add(:base, "second element of each events_order tuple must be #{EXPRESSION_TYPES.to_sentence(last_word_connector: ' or ')}") | |
| 124 | + break | |
| 125 | + end | |
| 126 | + if !desc.nil? && boolify(desc).nil? | |
| 127 | + errors.add(:base, "third element of each events_order tuple must be a boolean value") | |
| 128 | + break | |
| 129 | + end | |
| 130 | + end | |
| 131 | + else | |
| 132 | + errors.add(:base, "events_order must be an array of arrays") | |
| 133 | + end | |
| 134 | + end | |
| 135 | + | |
| 136 | + # Sort given events in order specified by the "events_order" option | |
| 137 | + def sort_events(events) | |
| 138 | + order_by = events_order.presence or | |
| 139 | + return events | |
| 140 | + | |
| 141 | +    orders = order_by.map { |_, _, desc = false| boolify(desc) } | |
| 142 | + | |
| 143 | + Utils.sort_tuples!( | |
| 144 | +      events.map.with_index { |event, index| | |
| 145 | +        interpolate_with(event) { | |
| 146 | + interpolation_context['_index_'] = index | |
| 147 | +          order_by.map { |expression, type, _| | |
| 148 | + string = interpolate_string(expression) | |
| 149 | + begin | |
| 150 | + EXPRESSION_PARSER[type || 'string'.freeze][string] | |
| 151 | + rescue | |
| 152 | +              error "Cannot parse #{string.inspect} as #{type}; treating it as string" | |
| 153 | + string | |
| 154 | + end | |
| 155 | + } | |
| 156 | + } << index << event # index is to make sorting stable | |
| 157 | + }, | |
| 158 | + orders | |
| 159 | + ).collect!(&:last) | |
| 160 | + end | |
| 161 | +end | 
| @@ -80,6 +80,7 @@ module ApplicationHelper | ||
| 80 | 80 | end | 
| 81 | 81 |  | 
| 82 | 82 | def service_label(service) | 
| 83 | + return if service.nil? | |
| 83 | 84 | content_tag :span, [ | 
| 84 | 85 | omniauth_provider_icon(service.provider), | 
| 85 | 86 | service_label_text(service) | 
| @@ -13,6 +13,7 @@ class Agent < ActiveRecord::Base | ||
| 13 | 13 | include HasGuid | 
| 14 | 14 | include LiquidDroppable | 
| 15 | 15 | include DryRunnable | 
| 16 | + include SortableEvents | |
| 16 | 17 |  | 
| 17 | 18 | markdown_class_attributes :description, :event_description | 
| 18 | 19 |  | 
| @@ -104,12 +105,19 @@ class Agent < ActiveRecord::Base | ||
| 104 | 105 | raise "Implement me in your subclass" | 
| 105 | 106 | end | 
| 106 | 107 |  | 
| 107 | - def create_event(attrs) | |
| 108 | + def build_event(event) | |
| 109 | + event = events.build(event) if event.is_a?(Hash) | |
| 110 | + event.agent = self | |
| 111 | + event.user = user | |
| 112 | + event.expires_at ||= new_event_expiration_date | |
| 113 | + event | |
| 114 | + end | |
| 115 | + | |
| 116 | + def create_event(event) | |
| 108 | 117 | if can_create_events? | 
| 109 | -      events.create!({ | |
| 110 | - :user => user, | |
| 111 | - :expires_at => new_event_expiration_date | |
| 112 | - }.merge(attrs)) | |
| 118 | + event = build_event(event) | |
| 119 | + event.save! | |
| 120 | + event | |
| 113 | 121 | else | 
| 114 | 122 | error "This Agent cannot create events!" | 
| 115 | 123 | end | 
| @@ -40,11 +40,15 @@ module Agents | ||
| 40 | 40 | "_contents": "tag contents (can be an object for nesting)" | 
| 41 | 41 | } | 
| 42 | 42 |  | 
| 43 | + # Ordering events in the output | |
| 44 | + | |
| 45 | +        #{description_events_order('events in the output')} | |
| 46 | + | |
| 43 | 47 | # Liquid Templating | 
| 44 | 48 |  | 
| 45 | 49 | In Liquid templating, the following variable is available: | 
| 46 | 50 |  | 
| 47 | -        * `events`: An array of events being output, sorted in descending order up to `events_to_show` in number.  For example, if source events contain a site title in the `site_title` key, you can refer to it in `template.title` by putting `{{events.first.site_title}}`. | |
| 51 | +        * `events`: An array of events being output, sorted in the given order, up to `events_to_show` in number.  For example, if source events contain a site title in the `site_title` key, you can refer to it in `template.title` by putting `{{events.first.site_title}}`. | |
| 48 | 52 |  | 
| 49 | 53 | MD | 
| 50 | 54 | end | 
| @@ -134,7 +138,7 @@ module Agents | ||
| 134 | 138 | end | 
| 135 | 139 | end | 
| 136 | 140 |  | 
| 137 | - source_events = received_events.order(id: :desc).limit(events_to_show).to_a | |
| 141 | + source_events = sort_events(received_events.order(id: :desc).limit(events_to_show).to_a) | |
| 138 | 142 |  | 
| 139 | 143 | interpolation_context.stack do | 
| 140 | 144 | interpolation_context['events'] = source_events | 
| @@ -9,6 +9,8 @@ module Agents | ||
| 9 | 9 | can_dry_run! | 
| 10 | 10 | default_schedule "every_1d" | 
| 11 | 11 |  | 
| 12 | +    DEFAULT_EVENTS_ORDER = [['{{date_published}}', 'time'], ['{{last_updated}}', 'time']] | |
| 13 | + | |
| 12 | 14 | description do | 
| 13 | 15 | <<-MD | 
| 14 | 16 | This Agent consumes RSS feeds and emits events when they change. | 
| @@ -29,6 +31,12 @@ module Agents | ||
| 29 | 31 | * `disable_url_encoding` - Set to `true` to disable url encoding. | 
| 30 | 32 |            * `user_agent` - A custom User-Agent name (default: "Faraday v#{Faraday::VERSION}"). | 
| 31 | 33 | * `max_events_per_run` - Limit number of events created (items parsed) per run for feed. | 
| 34 | + | |
| 35 | + # Ordering Events | |
| 36 | + | |
| 37 | +        #{description_events_order} | |
| 38 | + | |
| 39 | +        In this Agent, the default value for `events_order` is `#{DEFAULT_EVENTS_ORDER.to_json}`. | |
| 32 | 40 | MD | 
| 33 | 41 | end | 
| 34 | 42 |  | 
| @@ -70,6 +78,11 @@ module Agents | ||
| 70 | 78 | end | 
| 71 | 79 |  | 
| 72 | 80 | validate_web_request_options! | 
| 81 | + validate_events_order | |
| 82 | + end | |
| 83 | + | |
| 84 | + def events_order | |
| 85 | + super.presence || DEFAULT_EVENTS_ORDER | |
| 73 | 86 | end | 
| 74 | 87 |  | 
| 75 | 88 | def check | 
| @@ -84,26 +97,15 @@ module Agents | ||
| 84 | 97 | response = faraday.get(url) | 
| 85 | 98 | if response.success? | 
| 86 | 99 | feed = FeedNormalizer::FeedNormalizer.parse(response.body) | 
| 87 | - feed.clean! if interpolated['clean'] == 'true' | |
| 100 | + feed.clean! if boolify(interpolated['clean']) | |
| 88 | 101 | max_events = (interpolated['max_events_per_run'].presence || 0).to_i | 
| 89 | 102 | created_event_count = 0 | 
| 90 | -        feed.entries.sort_by { |entry| [entry.date_published, entry.last_updated] }.each.with_index do |entry, index| | |
| 103 | + sort_events(feed_to_events(feed)).each.with_index do |event, index| | |
| 91 | 104 | break if max_events && max_events > 0 && index >= max_events | 
| 92 | - entry_id = get_entry_id(entry) | |
| 105 | + entry_id = event.payload[:id] | |
| 93 | 106 | if check_and_track(entry_id) | 
| 94 | 107 | created_event_count += 1 | 
| 95 | -            create_event(payload: { | |
| 96 | - id: entry_id, | |
| 97 | - date_published: entry.date_published, | |
| 98 | - last_updated: entry.last_updated, | |
| 99 | - url: entry.url, | |
| 100 | - urls: entry.urls, | |
| 101 | - description: entry.description, | |
| 102 | - content: entry.content, | |
| 103 | - title: entry.title, | |
| 104 | - authors: entry.authors, | |
| 105 | - categories: entry.categories | |
| 106 | - }) | |
| 108 | + create_event(event) | |
| 107 | 109 | end | 
| 108 | 110 | end | 
| 109 | 111 |          log "Fetched #{url} and created #{created_event_count} event(s)." | 
| @@ -128,5 +130,22 @@ module Agents | ||
| 128 | 130 | true | 
| 129 | 131 | end | 
| 130 | 132 | end | 
| 133 | + | |
| 134 | + def feed_to_events(feed) | |
| 135 | +      feed.entries.map { |entry| | |
| 136 | +        Event.new(payload: { | |
| 137 | + id: get_entry_id(entry), | |
| 138 | + date_published: entry.date_published, | |
| 139 | + last_updated: entry.last_updated, | |
| 140 | + url: entry.url, | |
| 141 | + urls: entry.urls, | |
| 142 | + description: entry.description, | |
| 143 | + content: entry.content, | |
| 144 | + title: entry.title, | |
| 145 | + authors: entry.authors, | |
| 146 | + categories: entry.categories | |
| 147 | + }) | |
| 148 | + } | |
| 149 | + end | |
| 131 | 150 | end | 
| 132 | 151 | end | 
| @@ -6,6 +6,7 @@ module Agents | ||
| 6 | 6 | include WebRequestConcern | 
| 7 | 7 |  | 
| 8 | 8 | can_dry_run! | 
| 9 | + can_order_created_events! | |
| 9 | 10 |  | 
| 10 | 11 | default_schedule "every_12h" | 
| 11 | 12 |  | 
| @@ -105,6 +106,10 @@ module Agents | ||
| 105 | 106 | * `status`: HTTP status as integer. (Almost always 200) | 
| 106 | 107 |  | 
| 107 | 108 |            * `headers`: Response headers; for example, `{{ _response_.headers.Content-Type }}` expands to the value of the Content-Type header.  Keys are insensitive to cases and -/_. | 
| 109 | + | |
| 110 | + # Ordering Events | |
| 111 | + | |
| 112 | +      #{description_events_order} | |
| 108 | 113 | MD | 
| 109 | 114 |  | 
| 110 | 115 | event_description do | 
| @@ -278,21 +278,6 @@ class ScenarioImport | ||
| 278 | 278 | yield 'disabled', disabled, boolean if disabled.requires_merge? | 
| 279 | 279 | end | 
| 280 | 280 |  | 
| 281 | - # Unfortunately Ruby 1.9's OpenStruct doesn't expose [] and []=. | |
| 282 | - unless instance_methods.include?(:[]=) | |
| 283 | - def [](key) | |
| 284 | - self.send(sanitize key) | |
| 285 | - end | |
| 286 | - | |
| 287 | - def []=(key, val) | |
| 288 | -        self.send("#{sanitize key}=", val) | |
| 289 | - end | |
| 290 | - | |
| 291 | - def sanitize(key) | |
| 292 | - key.gsub(/[^a-zA-Z0-9_-]/, '') | |
| 293 | - end | |
| 294 | - end | |
| 295 | - | |
| 296 | 281 | def agent_instance | 
| 297 | 282 |        "Agents::#{self.type.updated}".constantize.new | 
| 298 | 283 | end | 
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | Delayed::Worker.destroy_failed_jobs = false | 
| 2 | 2 | Delayed::Worker.max_attempts = 5 | 
| 3 | -Delayed::Worker.max_run_time = (ENV['DELAYED_JOB_MAX_RUNTIME'].presence || 20).to_i.minutes | |
| 3 | +Delayed::Worker.max_run_time = (ENV['DELAYED_JOB_MAX_RUNTIME'].presence || 2).to_i.minutes | |
| 4 | 4 | Delayed::Worker.read_ahead = 5 | 
| 5 | 5 | Delayed::Worker.default_priority = 10 | 
| 6 | 6 | Delayed::Worker.delay_jobs = !Rails.env.test? | 
| @@ -1,6 +1,3 @@ | ||
| 1 | -# Module#prepend support for Ruby 1.9 | |
| 2 | -require 'prepend' unless Module.method_defined?(:prepend) | |
| 3 | - | |
| 4 | 1 | require 'active_support' | 
| 5 | 2 |  | 
| 6 | 3 | ActiveSupport.on_load :active_record do | 
| @@ -1,85 +0,0 @@ | ||
| 1 | -# Fake implementation of prepend(), which does not support overriding | |
| 2 | -# inherited methods nor methods that are formerly overridden by | |
| 3 | -# another invocation of prepend(). | |
| 4 | -# | |
| 5 | -# Here's what <Original>.prepend(<Wrapper>) does: | |
| 6 | -# | |
| 7 | -# - Create an anonymous stub module (hereinafter <Stub>) and define | |
| 8 | -# <Stub>#<method> that calls #<method>_without_<Wrapper> for each | |
| 9 | -# instance method of <Wrapper>. | |
| 10 | -# | |
| 11 | -# - Rename <Original>#<method> to #<method>_without_<Wrapper> for each | |
| 12 | -# instance method of <Wrapper>. | |
| 13 | -# | |
| 14 | -# - Include <Stub> and <Wrapper> into <Original> in that order. | |
| 15 | -# | |
| 16 | -# This way, a call of <Original>#<method> is dispatched to | |
| 17 | -# <Wrapper><method>, which may call super which is dispatched to | |
| 18 | -# <Stub>#<method>, which finally calls | |
| 19 | -# <Original>#<method>_without_<Wrapper> which is used to be called | |
| 20 | -# <Original>#<method>. | |
| 21 | -# | |
| 22 | -# Usage: | |
| 23 | -# | |
| 24 | -# class Mechanize | |
| 25 | -# # module with methods that overrides those of X | |
| 26 | -# module Y | |
| 27 | -# end | |
| 28 | -# | |
| 29 | -# unless X.respond_to?(:prepend, true) | |
| 30 | -# require 'mechanize/prependable' | |
| 31 | -# X.extend(Prependable) | |
| 32 | -# end | |
| 33 | -# | |
| 34 | -# class X | |
| 35 | -# prepend Y | |
| 36 | -# end | |
| 37 | -# end | |
| 38 | -class Module | |
| 39 | - def prepend(mod) | |
| 40 | - stub = Module.new | |
| 41 | - | |
| 42 | - mod_id = (mod.name || 'Module__%d' % mod.object_id).gsub(/::/, '__') | |
| 43 | - | |
| 44 | -    mod.instance_methods.each { |name| | |
| 45 | - method_defined?(name) or next | |
| 46 | - | |
| 47 | - original = instance_method(name) | |
| 48 | - next if original.owner != self | |
| 49 | - | |
| 50 | - name = name.to_s | |
| 51 | -      name_without = name.sub(/(?=[?!=]?\z)/) { '_without_%s' % mod_id } | |
| 52 | - | |
| 53 | - arity = original.arity | |
| 54 | - arglist = ( | |
| 55 | - if arity >= 0 | |
| 56 | -          (1..arity).map { |i| 'x%d' % i } | |
| 57 | - else | |
| 58 | -          (1..(-arity - 1)).map { |i| 'x%d' % i } << '*a' | |
| 59 | - end << '&b' | |
| 60 | -      ).join(', ') | |
| 61 | - | |
| 62 | -      if name.end_with?('=') | |
| 63 | -        stub.module_eval %{ | |
| 64 | -          def #{name}(#{arglist}) | |
| 65 | -            __send__(:#{name_without}, #{arglist}) | |
| 66 | - end | |
| 67 | - } | |
| 68 | - else | |
| 69 | -        stub.module_eval %{ | |
| 70 | -          def #{name}(#{arglist}) | |
| 71 | -            #{name_without}(#{arglist}) | |
| 72 | - end | |
| 73 | - } | |
| 74 | - end | |
| 75 | -      module_eval { | |
| 76 | - alias_method name_without, name | |
| 77 | - remove_method name | |
| 78 | - } | |
| 79 | - } | |
| 80 | - | |
| 81 | - include stub | |
| 82 | - include mod | |
| 83 | - end | |
| 84 | - private :prepend | |
| 85 | -end unless Module.method_defined?(:prepend) | 
| @@ -79,4 +79,43 @@ module Utils | ||
| 79 | 79 | def self.pretty_jsonify(thing) | 
| 80 | 80 |      JSON.pretty_generate(thing).gsub('</', '<\/') | 
| 81 | 81 | end | 
| 82 | + | |
| 83 | + class TupleSorter | |
| 84 | + class SortableTuple | |
| 85 | + attr_reader :array | |
| 86 | + | |
| 87 | + # The <=> method will call orders[n] to determine if the nth element | |
| 88 | + # should be compared in descending order. | |
| 89 | + def initialize(array, orders = []) | |
| 90 | + @array = array | |
| 91 | + @orders = orders | |
| 92 | + end | |
| 93 | + | |
| 94 | + def <=> other | |
| 95 | + other = other.array | |
| 96 | + @array.each_with_index do |e, i| | |
| 97 | + o = other[i] | |
| 98 | + case cmp = e <=> o || e.to_s <=> o.to_s | |
| 99 | + when 0 | |
| 100 | + next | |
| 101 | + else | |
| 102 | + return @orders[i] ? -cmp : cmp | |
| 103 | + end | |
| 104 | + end | |
| 105 | + 0 | |
| 106 | + end | |
| 107 | + end | |
| 108 | + | |
| 109 | + class << self | |
| 110 | + def sort!(array, orders = []) | |
| 111 | + array.sort_by! do |e| | |
| 112 | + SortableTuple.new(e, orders) | |
| 113 | + end | |
| 114 | + end | |
| 115 | + end | |
| 116 | + end | |
| 117 | + | |
| 118 | + def self.sort_tuples!(array, orders = []) | |
| 119 | + TupleSorter.sort!(array, orders) | |
| 120 | + end | |
| 82 | 121 | end | 
| @@ -0,0 +1,264 @@ | ||
| 1 | +require 'spec_helper' | |
| 2 | + | |
| 3 | +describe SortableEvents do | |
| 4 | +  let(:agent_class) { | |
| 5 | + Class.new(Agent) do | |
| 6 | + include SortableEvents | |
| 7 | + | |
| 8 | + default_schedule 'never' | |
| 9 | + | |
| 10 | + def self.valid_type?(name) | |
| 11 | + true | |
| 12 | + end | |
| 13 | + end | |
| 14 | + } | |
| 15 | + | |
| 16 | + def new_agent(events_order = nil) | |
| 17 | +    options = {} | |
| 18 | + options['events_order'] = events_order if events_order | |
| 19 | +    agent_class.new(name: 'test', options: options) { |agent| | |
| 20 | + agent.user = users(:bob) | |
| 21 | + } | |
| 22 | + end | |
| 23 | + | |
| 24 | + describe 'validations' do | |
| 25 | +    let(:agent_class) { | |
| 26 | + Class.new(Agent) do | |
| 27 | + include SortableEvents | |
| 28 | + | |
| 29 | + default_schedule 'never' | |
| 30 | + | |
| 31 | + def self.valid_type?(name) | |
| 32 | + true | |
| 33 | + end | |
| 34 | + end | |
| 35 | + } | |
| 36 | + | |
| 37 | + def new_agent(events_order = nil) | |
| 38 | +      options = {} | |
| 39 | + options['events_order'] = events_order if events_order | |
| 40 | +      agent_class.new(name: 'test', options: options) { |agent| | |
| 41 | + agent.user = users(:bob) | |
| 42 | + } | |
| 43 | + end | |
| 44 | + | |
| 45 | + it 'should allow events_order to be unspecified, null or an empty array' do | |
| 46 | + expect(new_agent()).to be_valid | |
| 47 | + expect(new_agent(nil)).to be_valid | |
| 48 | + expect(new_agent([])).to be_valid | |
| 49 | + end | |
| 50 | + | |
| 51 | + it 'should not allow events_order to be a non-array object' do | |
| 52 | + agent = new_agent(0) | |
| 53 | + expect(agent).not_to be_valid | |
| 54 | + expect(agent.errors[:base]).to include(/events_order/) | |
| 55 | + | |
| 56 | +      agent = new_agent('') | |
| 57 | + expect(agent).not_to be_valid | |
| 58 | + expect(agent.errors[:base]).to include(/events_order/) | |
| 59 | + | |
| 60 | +      agent = new_agent({}) | |
| 61 | + expect(agent).not_to be_valid | |
| 62 | + expect(agent.errors[:base]).to include(/events_order/) | |
| 63 | + end | |
| 64 | + | |
| 65 | + it 'should not allow events_order to be an array containing unexpected objects' do | |
| 66 | +      agent = new_agent(['{{key}}', 1]) | |
| 67 | + expect(agent).not_to be_valid | |
| 68 | + expect(agent.errors[:base]).to include(/events_order/) | |
| 69 | + | |
| 70 | +      agent = new_agent(['{{key1}}', ['{{key2}}', 'unknown']]) | |
| 71 | + expect(agent).not_to be_valid | |
| 72 | + expect(agent.errors[:base]).to include(/events_order/) | |
| 73 | + end | |
| 74 | + | |
| 75 | + it 'should allow events_order to be an array containing strings and valid tuples' do | |
| 76 | +      agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number']]) | |
| 77 | + expect(agent).to be_valid | |
| 78 | + | |
| 79 | +      agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number'], ['{{key4}}', 'time', true]]) | |
| 80 | + expect(agent).to be_valid | |
| 81 | + end | |
| 82 | + end | |
| 83 | + | |
| 84 | + describe 'sort_events' do | |
| 85 | +    let(:payloads) { | |
| 86 | + [ | |
| 87 | +        { 'title' => 'TitleA', 'score' => 4,  'updated_on' => '7 Jul 2015' }, | |
| 88 | +        { 'title' => 'TitleB', 'score' => 2,  'updated_on' => '25 Jun 2014' }, | |
| 89 | +        { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' }, | |
| 90 | +        { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' }, | |
| 91 | + ] | |
| 92 | + } | |
| 93 | + | |
| 94 | +    let(:events) { | |
| 95 | +      payloads.map { |payload| Event.new(payload: payload) } | |
| 96 | + } | |
| 97 | + | |
| 98 | + it 'should sort events by a given key' do | |
| 99 | +      agent = new_agent(['{{title}}']) | |
| 100 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleC TitleD]) | |
| 101 | + | |
| 102 | +      agent = new_agent([['{{title}}', 'string', true]]) | |
| 103 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleD TitleC TitleB TitleA]) | |
| 104 | + end | |
| 105 | + | |
| 106 | + it 'should sort events by multiple keys' do | |
| 107 | +      agent = new_agent([['{{score}}', 'number'], '{{title}}']) | |
| 108 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleC TitleD]) | |
| 109 | + | |
| 110 | +      agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]]) | |
| 111 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC]) | |
| 112 | + end | |
| 113 | + | |
| 114 | + it 'should sort events by time' do | |
| 115 | +      agent = new_agent([['{{updated_on}}', 'time']]) | |
| 116 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleD TitleC TitleA]) | |
| 117 | + end | |
| 118 | + | |
| 119 | + it 'should sort events stably' do | |
| 120 | + agent = new_agent(['<constant>']) | |
| 121 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC]) | |
| 122 | + | |
| 123 | + agent = new_agent([['<constant>', 'string', true]]) | |
| 124 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC]) | |
| 125 | + end | |
| 126 | + | |
| 127 | + it 'should support _index_' do | |
| 128 | +      agent = new_agent([['{{_index_}}', 'number', true]]) | |
| 129 | +      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleC TitleD TitleB TitleA]) | |
| 130 | + end | |
| 131 | + end | |
| 132 | + | |
| 133 | + describe 'automatic event sorter' do | |
| 134 | + describe 'declaration' do | |
| 135 | +      let(:passive_agent_class) { | |
| 136 | + Class.new(Agent) do | |
| 137 | + include SortableEvents | |
| 138 | + | |
| 139 | + cannot_create_events! | |
| 140 | + end | |
| 141 | + } | |
| 142 | + | |
| 143 | +      let(:active_agent_class) { | |
| 144 | + Class.new(Agent) do | |
| 145 | + include SortableEvents | |
| 146 | + end | |
| 147 | + } | |
| 148 | + | |
| 149 | + describe 'can_order_created_events!' do | |
| 150 | + it 'should refuse to work if called from an Agent that cannot create events' do | |
| 151 | +          expect { | |
| 152 | + passive_agent_class.class_eval do | |
| 153 | + can_order_created_events! | |
| 154 | + end | |
| 155 | + }.to raise_error | |
| 156 | + end | |
| 157 | + | |
| 158 | + it 'should work if called from an Agent that can create events' do | |
| 159 | +          expect { | |
| 160 | + active_agent_class.class_eval do | |
| 161 | + can_order_created_events! | |
| 162 | + end | |
| 163 | + }.not_to raise_error | |
| 164 | + end | |
| 165 | + end | |
| 166 | + | |
| 167 | + describe 'can_order_created_events?' do | |
| 168 | + it 'should return false unless an Agent declares can_order_created_events!' do | |
| 169 | + expect(active_agent_class.can_order_created_events?).to eq(false) | |
| 170 | + expect(active_agent_class.new.can_order_created_events?).to eq(false) | |
| 171 | + end | |
| 172 | + | |
| 173 | + it 'should return true if an Agent declares can_order_created_events!' do | |
| 174 | + active_agent_class.class_eval do | |
| 175 | + can_order_created_events! | |
| 176 | + end | |
| 177 | + | |
| 178 | + expect(active_agent_class.can_order_created_events?).to eq(true) | |
| 179 | + expect(active_agent_class.new.can_order_created_events?).to eq(true) | |
| 180 | + end | |
| 181 | + end | |
| 182 | + end | |
| 183 | + | |
| 184 | + describe 'behavior' do | |
| 185 | + class Agents::EventOrderableAgent < Agent | |
| 186 | + include SortableEvents | |
| 187 | + | |
| 188 | + default_schedule 'never' | |
| 189 | + | |
| 190 | + can_order_created_events! | |
| 191 | + | |
| 192 | + attr_accessor :payloads_to_emit | |
| 193 | + | |
| 194 | + def self.valid_type?(name) | |
| 195 | + true | |
| 196 | + end | |
| 197 | + | |
| 198 | + def check | |
| 199 | + payloads_to_emit.each do |payload| | |
| 200 | + create_event payload: payload | |
| 201 | + end | |
| 202 | + end | |
| 203 | + | |
| 204 | + def receive(events) | |
| 205 | + events.each do |event| | |
| 206 | + payloads_to_emit.each do |payload| | |
| 207 | +              create_event payload: payload.merge('title' => payload['title'] + event.payload['title_suffix']) | |
| 208 | + end | |
| 209 | + end | |
| 210 | + end | |
| 211 | + end | |
| 212 | + | |
| 213 | + def new_agent(events_order = nil) | |
| 214 | +        options = {} | |
| 215 | + options['events_order'] = events_order if events_order | |
| 216 | +        Agents::EventOrderableAgent.new(name: 'test', options: options) { |agent| | |
| 217 | + agent.user = users(:bob) | |
| 218 | + agent.payloads_to_emit = payloads | |
| 219 | + } | |
| 220 | + end | |
| 221 | + | |
| 222 | +      let(:payloads) { | |
| 223 | + [ | |
| 224 | +          { 'title' => 'TitleA', 'score' => 4,  'updated_on' => '7 Jul 2015' }, | |
| 225 | +          { 'title' => 'TitleB', 'score' => 2,  'updated_on' => '25 Jun 2014' }, | |
| 226 | +          { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' }, | |
| 227 | +          { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' }, | |
| 228 | + ] | |
| 229 | + } | |
| 230 | + | |
| 231 | + it 'should keep the order of created events unless events_order is specified' do | |
| 232 | + [[], [nil], [[]]].each do |args| | |
| 233 | + agent = new_agent(*args) | |
| 234 | + agent.save! | |
| 235 | +          expect { agent.check }.to change { Event.count }.by(4) | |
| 236 | + events = agent.events.last(4).sort_by(&:id) | |
| 237 | +          expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC]) | |
| 238 | + end | |
| 239 | + end | |
| 240 | + | |
| 241 | + it 'should sort events created in check() in the order specified in events_order' do | |
| 242 | +        agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]]) | |
| 243 | + agent.save! | |
| 244 | +        expect { agent.check }.to change { Event.count }.by(4) | |
| 245 | + events = agent.events.last(4).sort_by(&:id) | |
| 246 | +        expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC]) | |
| 247 | + end | |
| 248 | + | |
| 249 | + it 'should sort events created in receive() in the order specified in events_order' do | |
| 250 | +        agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]]) | |
| 251 | + agent.save! | |
| 252 | +        expect { | |
| 253 | +          agent.receive([Event.new(payload: { 'title_suffix' => ' [new]' }), | |
| 254 | +                         Event.new(payload: { 'title_suffix' => ' [popular]' })]) | |
| 255 | +        }.to change { Event.count }.by(8) | |
| 256 | + events = agent.events.last(8).sort_by(&:id) | |
| 257 | +        expect(events.map { |event| event.payload['title'] }).to eq([ | |
| 258 | + 'TitleB [new]', 'TitleA [new]', 'TitleD [new]', 'TitleC [new]', | |
| 259 | + 'TitleB [popular]', 'TitleA [popular]', 'TitleD [popular]', 'TitleC [popular]', | |
| 260 | + ]) | |
| 261 | + end | |
| 262 | + end | |
| 263 | + end | |
| 264 | +end | 
| @@ -372,7 +372,7 @@ describe AgentsController do | ||
| 372 | 372 | sign_in users(:bob) | 
| 373 | 373 | agent = agents(:bob_weather_agent) | 
| 374 | 374 |        expect { | 
| 375 | - post :dry_run, id: agents(:bob_website_agent), agent: valid_attributes(name: 'New Name') | |
| 375 | + post :dry_run, id: agent, agent: valid_attributes(name: 'New Name') | |
| 376 | 376 |        }.not_to change { | 
| 377 | 377 | [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count, agent.name, agent.updated_at] | 
| 378 | 378 | } | 
| @@ -114,4 +114,62 @@ describe Utils do | ||
| 114 | 114 |        expect(cleaned_json).to include("<\\/script>") | 
| 115 | 115 | end | 
| 116 | 116 | end | 
| 117 | + | |
| 118 | + describe "#sort_tuples!" do | |
| 119 | +    let(:tuples) { | |
| 120 | + time = Time.now | |
| 121 | + [ | |
| 122 | + [2, "a", time - 1], # 0 | |
| 123 | + [2, "b", time - 1], # 1 | |
| 124 | + [1, "b", time - 1], # 2 | |
| 125 | + [1, "b", time], # 3 | |
| 126 | + [1, "a", time], # 4 | |
| 127 | + [2, "a", time + 1], # 5 | |
| 128 | + [2, "a", time], # 6 | |
| 129 | + ] | |
| 130 | + } | |
| 131 | + | |
| 132 | + it "sorts tuples like arrays by default" do | |
| 133 | + expected = tuples.values_at(4, 2, 3, 0, 6, 5, 1) | |
| 134 | + | |
| 135 | + Utils.sort_tuples!(tuples) | |
| 136 | + expect(tuples).to eq expected | |
| 137 | + end | |
| 138 | + | |
| 139 | + it "sorts tuples in order specified: case 1" do | |
| 140 | + # order by x1 asc, x2 desc, c3 asc | |
| 141 | + orders = [false, true, false] | |
| 142 | + expected = tuples.values_at(2, 3, 4, 1, 0, 6, 5) | |
| 143 | + | |
| 144 | + Utils.sort_tuples!(tuples, orders) | |
| 145 | + expect(tuples).to eq expected | |
| 146 | + end | |
| 147 | + | |
| 148 | + it "sorts tuples in order specified: case 2" do | |
| 149 | + # order by x1 desc, x2 asc, c3 desc | |
| 150 | + orders = [true, false, true] | |
| 151 | + expected = tuples.values_at(5, 6, 0, 1, 4, 3, 2) | |
| 152 | + | |
| 153 | + Utils.sort_tuples!(tuples, orders) | |
| 154 | + expect(tuples).to eq expected | |
| 155 | + end | |
| 156 | + | |
| 157 | + it "always succeeds in sorting even if it finds pairs of incomparable objects" do | |
| 158 | + time = Time.now | |
| 159 | + tuples = [ | |
| 160 | + [2, "a", time - 1], # 0 | |
| 161 | + [1, "b", nil], # 1 | |
| 162 | + [1, "b", time], # 2 | |
| 163 | + ["2", nil, time], # 3 | |
| 164 | + [1, nil, time], # 4 | |
| 165 | + [nil, "a", time + 1], # 5 | |
| 166 | + [2, "a", time], # 6 | |
| 167 | + ] | |
| 168 | + orders = [true, false, true] | |
| 169 | + expected = tuples.values_at(3, 6, 0, 4, 2, 1, 5) | |
| 170 | + | |
| 171 | + Utils.sort_tuples!(tuples, orders) | |
| 172 | + expect(tuples).to eq expected | |
| 173 | + end | |
| 174 | + end | |
| 117 | 175 | end | 
| @@ -209,6 +209,22 @@ describe Agents::DataOutputAgent do | ||
| 209 | 209 | }) | 
| 210 | 210 | end | 
| 211 | 211 |  | 
| 212 | + describe 'ordering' do | |
| 213 | + before do | |
| 214 | +          agent.options['events_order'] = ['{{title}}'] | |
| 215 | + end | |
| 216 | + | |
| 217 | + it 'can reorder the events_to_show last events based on a Liquid expression' do | |
| 218 | +          asc_content, _status, _content_type = agent.receive_web_request({ 'secret' => 'secret2' }, 'get', 'application/json') | |
| 219 | +          expect(asc_content['items'].map {|i| i["title"] }).to eq(["Evolving", "Evolving again", "Evolving yet again with a past date"]) | |
| 220 | + | |
| 221 | +          agent.options['events_order'] = [['{{title}}', 'string', true]] | |
| 222 | + | |
| 223 | +          desc_content, _status, _content_type = agent.receive_web_request({ 'secret' => 'secret2' }, 'get', 'application/json') | |
| 224 | + expect(desc_content['items']).to eq(asc_content['items'].reverse) | |
| 225 | + end | |
| 226 | + end | |
| 227 | + | |
| 212 | 228 | describe "interpolating \"events\"" do | 
| 213 | 229 | before do | 
| 214 | 230 |            agent.options['template']['title'] = "XKCD comics as a feed{% if events.first.site_title %} ({{events.first.site_title}}){% endif %}" | 
| @@ -66,6 +66,21 @@ describe Agents::RssAgent do | ||
| 66 | 66 | expect(last.payload['urls']).to eq(["https://github.com/cantino/huginn/commit/d465158f77dcd9078697e6167b50abbfdfa8b1af"]) | 
| 67 | 67 | end | 
| 68 | 68 |  | 
| 69 | + it "should emit items as events in the order specified in the events_order option" do | |
| 70 | +      expect { | |
| 71 | +        agent.options['events_order'] = ['{{title | replace_regex: "^[[:space:]]+", "" }}'] | |
| 72 | + agent.check | |
| 73 | +      }.to change { agent.events.count }.by(20) | |
| 74 | + | |
| 75 | + first, *, last = agent.events.last(20) | |
| 76 | +      expect(first.payload['title'].strip).to eq('upgrade rails and gems') | |
| 77 | +      expect(first.payload['url']).to eq("https://github.com/cantino/huginn/commit/87a7abda23a82305d7050ac0bb400ce36c863d01") | |
| 78 | + expect(first.payload['urls']).to eq(["https://github.com/cantino/huginn/commit/87a7abda23a82305d7050ac0bb400ce36c863d01"]) | |
| 79 | +      expect(last.payload['title'].strip).to eq('Dashed line in a diagram indicates propagate_immediately being false.') | |
| 80 | +      expect(last.payload['url']).to eq("https://github.com/cantino/huginn/commit/0e80f5341587aace2c023b06eb9265b776ac4535") | |
| 81 | + expect(last.payload['urls']).to eq(["https://github.com/cantino/huginn/commit/0e80f5341587aace2c023b06eb9265b776ac4535"]) | |
| 82 | + end | |
| 83 | + | |
| 69 | 84 | it "should track ids and not re-emit the same item when seen again" do | 
| 70 | 85 | agent.check | 
| 71 | 86 |        expect(agent.memory['seen_ids']).to eq(agent.events.map {|e| e.payload['id'] }) |